﻿/*
 * Copyright 2013 Edward L. Stauff.                  Contact: <EdStauff@gmail.com>.
 * 
 * This file, "PathCollection.cs", is part of the "Stauffware.Common" class library.
 * "Stauffware.Common" is free software: you can redistribute it and/or 
 * modify it under the terms of the GNU Lesser Public License as published by the 
 * Free Software Foundation, either version 3 of the License, or (at your option) 
 * any later version.
 * 
 * "Stauffware.Common" is distributed in the hope that it will be useful, 
 * but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY 
 * or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Lesser Public License for more 
 * details.
 * 
 * You should have received a copy of the GNU Lesser Public License along with 
 * the "Stauffware.Common" sources.  If not, see <http://www.gnu.org/licenses/>.
 */

using System;
using System.Collections.Generic;
using System.Text;
using System.IO;
using Stauffware.Common.Core;

namespace Stauffware.Common.IO
{
	/// <summary>
	/// Options for collecting paths.
	/// </summary>
	[Flags]
	public enum PathCollectionOptions
	{
		/// <summary>
		/// Look in any sub-directories found in the specified input paths (recursively).
		/// </summary>
		LookInSubDirectories	= 0x01,

		/// <summary>
		/// Replace environment variable names with their values.  
		/// Environment variables are delimited with percent signs (%); case is ignored.
		/// Two adjacent percent signs "%%" are replaced with a single one "%".
		/// </summary>
		ResolveEnvVars			= 0x02,

		/// <summary>
		/// Resolve special folder names in paths.
		/// Special folder names are delimited with percent signs (%); 
		/// their names are the System.Environment.SpecialFolder enum values.
		/// The resolved values are obtained from System.Environment.GetFolderPath().
		/// Two adjacent percent signs "%%" are replaced with a single one "%".
		/// </summary>
		ResolveSpecialFolders	= 0x04,

		///// <summary>
		///// Defers resolution of wildcards and directories until the Files property
		///// is read, or until ResolvePaths() is called.  If this flag is not set,
		///// paths are resolved when they're added to this PathCollection.
		///// </summary>
		//DeferPathResolution		= 0x08,
	}

	//#################################################################################
	/// <summary>
	/// A collection of files that can be specified in a variety of ways,
	/// including wildcards and recursive directory searching.
	/// This class does not modify any file system objects.
	/// </summary>
	/// <remarks>
	/// PathCollection does *NOT* attempt to detect duplicates that are specified
	/// through different mapped or subst drives or network shares.
	/// PathCollection does not (yet) resolve shortcuts.
	/// </remarks>

	public class PathCollection
	{
		/// <summary> Options for how duplicate paths and files are handled. </summary>
		public enum DuplicatePaths
		{
			/// <summary> 
			/// The PathCollection processes each pathname or filename only once, 
			/// and silently ignores any duplicates. 
			/// </summary>
			AreDropped = 0,
			
			/// <summary> 
			/// The PathCollection performs no checking on paths or files 
			/// to see if they have already been handled. 
			/// </summary>
			AreKept,

			/// <summary>
			/// If the PathCollection encounters a pathname or filename that it has 
			/// already processed, it reports a non-fatal error.
			/// </summary>
			CauseError
		}

		/// <summary> Called for non-fatal errors. </summary>
		public delegate void ErrorDelegate (PathCollection sender, string path, Exception ex);

		/// <summary> Called to report progress during lengthy operations. </summary>
		public delegate void ProgressDelegate (PathCollection sender, string path, string message);

		/// <summary>
		/// Raised for each non-fatal error, as it occurs.
		/// ErrorDelegate (PathCollection sender, string path, Exception ex)
		/// </summary>
		public event ErrorDelegate ErrorReported;

		/// <summary>
		/// Raised for each file system object.  
		/// Use this to report progress during lengthy operations.
		/// </summary>
		public event ProgressDelegate ProgressReported;

		/// <summary>
		/// Gets or sets options for collecting paths.
		/// </summary>
		public PathCollectionOptions Options { get; set; }

		/// <summary> Gets how duplicate paths and files are handled. </summary>
		public DuplicatePaths Duplicates { get; private set; }

		/// <summary>
		/// Gets or sets the maximum number of errors to report before giving
		/// up and throwing an exception.  Default = 42.
		/// </summary>
		public int MaxErrors { get; set; }

		/// <summary>
		/// Gets the collection of paths that were explicitly added to this collection.
		/// This is a reference to the PathCollection's internal list, not a copy.
		/// </summary>
		public IList<string> Paths { get; private set; }

		/// <summary>
		/// Gets the list of files resulting from expanding the paths.
		/// This is a reference to the PathCollection's internal list, not a copy.
		/// </summary>
		public IList<string> Files { get; private set; }

		/// <summary>
		/// Gets the list of Exceptions thrown during processing.
		/// </summary>
		public IList<Exception> Exceptions { get; private set; }

		//-----------------------------------------------------------------------------

		#region Public Methods

		//-----------------------------------------------------------------------------
		/// <summary> constructor </summary>

		public PathCollection (DuplicatePaths dupes, PathCollectionOptions options = 0)
		{
			this.Exceptions = new List<Exception>();
			this.Paths = new List<string>();
			this.Files = new List<string>();
			this.MaxErrors = 42;
			this.Options = options;
			this.Duplicates = dupes;
		}

		//-----------------------------------------------------------------------------
		/// <summary>
		/// Adds a path to the collection.  The path can contain wildcards.
		/// </summary>

		public void Add (string path)
		{
			string fullPath = FileSystemPath.NormalizePath(FileSystemPath.GetFullPath(path), '\\');
			if (this.OkayToAdd(fullPath, this.Paths))
			{
				this.Paths.Add(fullPath);
				this.ResolvePath(fullPath);
			}
		}

		//-----------------------------------------------------------------------------
		/// <summary>
		/// Adds one or more paths to the collection.  Each path can contain wildcards.
		/// </summary>

		public void Add (IEnumerable<string> paths)
		{
			foreach (string path in paths)
				this.Add(path);
		}

		//-----------------------------------------------------------------------------
		/// <summary>
		/// Adds the paths specified in the given text file by passing each line in
		/// the file to Add().  Each line is stripped of leading &amp; trailing whitespace
		/// before being processed. Ignores blank lines and lines whose first non-space
		/// character is a colon (:) or vertical bar (|).
		/// </summary>

		public void AddFromFile (string pathListFile)
		{
			StreamReader sr = Open.FileStreamReader(pathListFile, FileShare.Read);
			try
			{
				this.AddFromLines(sr);
			}
			finally
			{
				sr.Close();
			}
		}

		//-----------------------------------------------------------------------------
		/// <summary>
		/// Adds the paths specified through the given text reader by passing each line in
		/// the file to Add().  Each line is stripped of leading &amp; trailing whitespace
		/// before being processed. Ignores blank lines and lines whose first non-space
		/// character is a colon (:) or vertical bar (|).
		/// </summary>

		public void AddFromLines (TextReader tr)
		{
			string line;
			while (null != (line = tr.ReadLine()))
			{
				line = line.Trim();
				if (line == "" || line.StartsWith(":") || line.StartsWith("|"))
					continue;
				this.Add(line);
			}
		}

		//-----------------------------------------------------------------------------
		/// <summary>
		/// Removes a file from the list of resolved files.
		/// Returns true on success, false if it wan't in the list.
		/// Does *NOT* handled wildcards.
		/// </summary>

		public bool RemoveFile (string path)
		{
			string fullPath = FileSystemPath.NormalizePath(FileSystemPath.GetFullPath(path), '\\');
			int n = FindPath(this.Files, fullPath);
			if (n < 0)
				return false;
			this.Files.RemoveAt(n);
			return true;
		}

		//-----------------------------------------------------------------------------
		#endregion

		#region Path Resolution
		//-----------------------------------------------------------------------------
		/// <summary>
		/// Examines the given path and calls AddFile, ExpandWildcard or ProcessDirectory
		/// as appropriate.
		/// </summary>

		private void ResolvePath (string srcPath)
		{
			if (this.Options.HasFlag(PathCollectionOptions.ResolveEnvVars))
				srcPath = FileSystemPath.ResolveEnvironmentVariables(srcPath);
			if (this.Options.HasFlag(PathCollectionOptions.ResolveSpecialFolders))
				srcPath = FileSystemPath.ResolveSpecialFolders(srcPath);

			if (FileSystemPath.ContainsWildcards(srcPath))
				this.ExpandWildcard(srcPath);
			else if (File.Exists(srcPath))
				this.AddFile(srcPath);
			else if (Directory.Exists(srcPath))
				this.ProcessDirectory(srcPath);
			else
				this.ReportError(srcPath, 
					new IOException("path does not exist, or is not a file or directory"));
		}

		//-----------------------------------------------------------------------------
		/// <summary> Processes files specified with a wildcard. </summary>

		private void ExpandWildcard (string srcPath)
		{
			string srcDir = Path.GetDirectoryName(srcPath);
			string pattern = Path.GetFileName(srcPath);

			this.ExpandWildcard(srcDir, pattern);
		}

		//-----------------------------------------------------------------------------
		/// <summary> Processes files specified with a wildcard. </summary>

		private void ExpandWildcard (string srcDir, string pattern)
		{
			string[] files = null;

			this.ReportProgress(srcDir, "collecting \"" + pattern + "\"");
			if (!this.TryIO(srcDir, ( ) => { files = Directory.GetFiles(srcDir, pattern); }))
				return;

			if (files == null || files.Length == 0)
				this.ReportProgress(srcDir + "\\" + pattern, "no matches found");
			else foreach (string srcFile in files)
					this.AddFile(srcFile);

			if (this.Options.HasFlag(PathCollectionOptions.LookInSubDirectories))
			{
				foreach (string subDir in Directory.GetDirectories(srcDir))
					this.ExpandWildcard(subDir, pattern);
			}
		}

		//-----------------------------------------------------------------------------
		/// <summary> Processes one directory, recursing as appropriate. </summary>

		private void ProcessDirectory (string srcDirPath)
		{
			this.ReportProgress(srcDirPath, "collecting \"" + srcDirPath + "\"");

			string[] fsEntries = null;
			if (!this.TryIO(srcDirPath, ( ) => { fsEntries = Directory.GetFileSystemEntries(srcDirPath); }))
				return;

			int filesFound = 0;

			foreach (string childSrcPath in fsEntries)
			{
				if (File.Exists(childSrcPath))
				{
					filesFound++;
					this.AddFile(childSrcPath);
				}
				else if (Directory.Exists(childSrcPath) && 
						 this.Options.HasFlag(PathCollectionOptions.LookInSubDirectories))
				{
					// MS docs don't say whether "." and ".." are returned by GetFileSystemEntries()
					string name = Path.GetFileName(childSrcPath);
					if (name == "." || name == "..")
						continue;
					this.ProcessDirectory(childSrcPath);
				}
			}

			if (filesFound == 0)
				this.ReportProgress(srcDirPath, "directory contains no files");
		}

		//-----------------------------------------------------------------------------
		/// <summary> Adds a plain ol' file path (not a directory or wildcard). </summary>

		private void AddFile (string path)
		{
			if (this.OkayToAdd(path, this.Files))
				this.Files.Add(path);
		}

		//-----------------------------------------------------------------------------
		#endregion

		//-----------------------------------------------------------------------------

		private bool OkayToAdd (string str, IList<string> list)
		{
			if (this.Duplicates == DuplicatePaths.AreKept)
				return true;
			if (FindPath(list, str) >= 0)
			{
				if (this.Duplicates == DuplicatePaths.CauseError)
					ReportError(str, new DuplicatePathException(str));
				return false;
			}
			return true;
		}

		//-----------------------------------------------------------------------------

		private static int FindPath (IList<string> list, string str)
		{
			for (int i = 0; i < list.Count; i++)
				if (str.Equals(list[i], StringComparison.CurrentCultureIgnoreCase))
					return i;
			return -1;
		}

		//-----------------------------------------------------------------------------
		/// <summary>
		/// Wrapper for I/O operations; catches and reports errors related to the file system.
		/// Returns true if the operation succeeded, false if it threw an exception.
		/// </summary>

		private bool TryIO (string path, Action ioAction)
		{
			try
			{
				ioAction();
				//	this._ReportProgress(path, "OK");
				return true;
			}
			catch (UnauthorizedAccessException x)
			{
				this.ReportError(path, x);
			}
			catch (IOException x)	// includes FileNotFoundException
			{
				this.ReportError(path, x);
			}

			return false;
		}

		//-----------------------------------------------------------------------------

		private void ReportProgress (string path, string message)
		{
			if (this.ProgressReported != null)
				this.ProgressReported(this, path, message);
		}

		//-----------------------------------------------------------------------------
		/// <summary> Reports an error without throwing an exception. </summary>

		private void ReportError (string path, Exception exc)
		{
			Assert.NotNull(path);
			Assert.NotNull(exc);
			exc.Data.Add("path", path);
			this.Exceptions.Add(exc);
			if (this.ErrorReported != null)
				this.ErrorReported(this, path, exc);
		}

		//-----------------------------------------------------------------------------
	}
	
	//#################################################################################
	/// <summary>
	/// Thrown by a PathCollection object to report a duplicate path.
	/// </summary>

	public class DuplicatePathException : Exception
	{
		/// <summary> The path in question. </summary>
		public readonly string Path;

		/// <summary> constructor </summary>
		public DuplicatePathException (string path)
			: base("path already specified: " + ErrorUtils.ValueToString(path))
		{
			this.Path = path;
		}
	}
}
